In the previous two lessons, we saw how Suspense can offer dramatically-improved loading experiences. But what exactly is Suspense? What does it do? How does it work?
In order to properly answer these questions, we need to go on a bit of a journey. The annoying truth is that Suspense has been evolving within React for years and years. Suspense is actually older than hooks! In order to understand the amazing results we saw in the last lesson, we need to rewind by a few years.
I'll warn you now, this lesson won't feel super related to what we just saw, with the sneaker store. I promise it all ties in though!
Our journey starts in 2018. Andrew Clark, a member of the React core team at Facebook, shared the following tweet:
You've probably used products like this. As each section loads, it shoves everything else out of the way, leading to a jarring and unpleasant user experience.
Unfortunately, component-driven frameworks like React tend to lead us in this direction. The whole idea with components is that we're packaging up all of the stuff for a particular part of the UI. If each component fetches its own data, we wind up in Spinner Hell.
Let's look at a quick code example, to see how this problem occurs:
When we render the <Dashboard /> component, we start off with two loading spinners. Both TrafficCard and OfferCard make network requests to fetch their data. When that data comes through, the component re-renders with the real UI.
Here's what will happen in practice. Click the green “play” button:
Traffic
Offer
The trouble is that each component is operating independently. They both make a request for data, and when it's received, they re-render immediately.
The more data-fetching elements we have, the more potential for chaos there is. The Facebook Ads Manager app has at least 6 individual data-fetching components, and the result is something like this:
Traffic
Offer
Placements
Audience
Budget
Insights
There's a technical term for when things move around like this: layout shift. Over the past few years, we've become increasingly aware of the problems associated with excessive layout shifts.
In 2020, Google created a new metric to help us measure the amount of layout shift. It's called Cumulative Layout Shift (opens in new tab) (CLS). If large chunks of your UI move around during the loading experience, you'll wind up with a poor CLS score.
Well, we could solve this by lifting the data-fetching requests up to the parent component, so that we can “group” their loading states. Maybe something like this?
functionDashboard(){
const{
data: trafficData,
isLoading: trafficIsLoading,
}=useSWR('/api/traffic', fetcher);
const{
data: offerData,
isLoading: offerIsLoading,
}=useSWR('/api/offer', fetcher);
// If *either* request is still pending, we'll show a spinner:
if(trafficIsLoading || offerIsLoading){
return<Spinner/>
}
return(
<>
<TrafficCarddata={trafficData}/>
<OfferCarddata={offerData}/>
</>
)
}
In this new version, we show a spinner until both network requests have resolved. Here's the result:
Traffic
Offer
Grouped
I think this is a better user experience. By waiting until both components are ready, we avoid the jarring experience of elements jumping around.
But it does feel like a step backwards in terms of developer experience. At least to me, it feels like components should own their data requests, the same way they own their styles, markup, and business logic.
Imagine if we had 6+ different network requests, like that Facebook Ads Manager app. We'd have half a dozen different loading variables in one component:
functionDashboard(){
const{
data: trafficData,
isLoading: trafficIsLoading,
}=useSWR('/api/traffic', fetcher);
const{
data: offerData,
isLoading: offerIsLoading,
}=useSWR('/api/offer', fetcher);
const{
data: placementsData,
isLoading: placementsIsLoading,
}=useSWR('/api/placements', fetcher);
const{
data: audienceData,
isLoading: audienceIsLoading,
}=useSWR('/api/audience', fetcher);
const{
data: budgetData,
isLoading: budgetIsLoading,
}=useSWR('/api/budget', fetcher);
const{
data: insightsData,
isLoading: insightsIsLoading,
}=useSWR('/api/insights', fetcher);
// ✂️ Rest of the code trimmed for brevity
}
I don't like this. 😬
What if there was a way for us to keep our original modular component structure, but to strategically “group” UI updates to avoid excessive layout shifts?
This is the problem that Suspense was originally designed to solve.
Introducing the Suspense component
Let's start with some code:
importReactfrom'react';
functionDashboard(){
return(
<React.Suspensefallback={<Spinner/>}>
<TrafficCard/>
<OfferCard/>
</React.Suspense>
);
}
Suspense is a special React component that can help us orchestrate loading states. In this case, we're saying that <TrafficCard> and <OfferCard> are part of the same “group”.
We specify our loading state with the fallback prop. React will render this fallback until all of its children have finished loading.
This produces the same user experience as our “lifting fetch up” approach:
Traffic
Offer
Grouped
When I first saw this sort of pattern, I found it completely baffling. How on earth does this work?!
In reality, the <React.Suspense> component is only half of the story. The other half of the Suspense API is that the components have to signal whether they're ready or not; they need to be able to say “Hey, don't render yet! I'm still loading my data.”.
A good analogy is that of a rock concert.
A rock show can't start until all of the band members have arrived. It doesn't matter if the guitarist, bassist, and drummer are there; if the lead singer is stuck in traffic, the whole band has to wait until he arrives before they can start playing. The show is suspended, temporarily, until all of the bandmates are ready.
That's where the name “Suspense” comes from. React will suspend rendering until all of the children have finished loading. The curtains stay closed until everyone's ready.
But how do individual components signal their loading state?
Well, this is where it gets tricky. Remember that React has very little insight into what actually happens inside our components. Let's suppose we were using the Fetch API to request our data, something like this:
functionTrafficCard(){
const[data, setData]=React.useState(null);
React.useEffect(()=>{
fetch('/api/traffic')
.then((res)=> res.json())
.then((data)=>{
setData(data);
});
},[]);
return<Card>{/* Stuff here */}</Card>;
}
In a situation like this, the only thing React knows is that we have some sort of side effect. React can't “see into” effects. The Fetch API is part of the web platform, not part of React, and so React doesn't even know that a network request is happening!
In other words, that <React.Suspense> parent has no way of knowing that TrafficCard is loading. We need some way to explicitly trigger the “suspend” state during the first render, and then to signal that the component is ready once the data has been received.
This part is intended to be done by library authors, not application developers. This is a rough edge, and the idea is that libraries and frameworks will provide a nicer interface for doing this.
In fact, we've already seen an example of a smooth edge: The loading.js component from the previous lesson uses Suspense under the hood! And libraries like SWR have an option to use Suspense (opens in new tab) under the hood.
But just for fun, for academic purposes… how does it work?
It turns out, it works almost exactly like error boundaries.
A new kind of boundary
In the previous module, we learned about Error Boundaries. We wrap an <ErrorBoundary> component around a slice of the React tree, and it will catch any errors thrown by any descendants, to keep the whole app from exploding.
As a quick reminder, the code for this setup looks like this:
functionApp(){
return(
<>
<Header/>
<ErrorBoundaryfallback="Something went wrong…">
<RealTimeInfo/>
</ErrorBoundary>
<Stories/>
</>
);
}
With this error boundary in position, any of the coloured components (RealTimeInfo, Ticker, and Price) can throw an error, and it'll cause the error boundary to swap out the <RealTimeInfo> element for the provided fallback (usually an error message).
<React.Suspense> also creates a boundary, but one that catches loading states instead of errors. We provide a loading fallback instead of an error fallback, and the component automatically swaps between them as-necessary.
Like error boundaries, Suspense boundaries will catch loading states in any descendants, not only the direct children. If SearchForm signals to the Suspense boundary that it's loading, this entire slice of the tree will be suspended, and we'll show the fallback instead.
Here's the really wild part: descendants communicate their loading state by broadcasting it using the throw keyword. But instead of throwing an error, we throw the fetch request itself.
Here's a very rough sketch of what this could look like:
functionTrafficCard(){
const[data, setData]=React.useState(null);
const[fetchRequest]=React.useState(()=>{
returnfetch('/api/traffic')
.then((res)=> res.json())
.then((data)=>{
setData(data);
});
});
if(fetchRequest.status==='pending'){
throw fetchRequest;
}
return<Card>{/* Stuff here */}</Card>;
}
What the heck is going on here?! Let me explain.
The Fetch API is Promise-based. This means that every fetch request will have a status property, and it will always be 1 of 3 values: "pending", "fulfilled", or "rejected".
I've created a new state variable, fetchRequest, which holds that promise. I'm keeping it in state so that I can take advantage of the initializer function syntax, so that this component only makes a single network request.
Typically, we'd put our network requests inside useEffect, but we can't do that here because effects run after render, and we want to interrupt (“suspend”) the render.
During the render, we check the status of that promise. If it's "pending", we throw it. This is what'll happen in the first render, since we've just started the request.
To put it mildly, this is unusual. The throw keyword is generally used for error-handling, like this:
functionregisterUser(user){
if(!user){
thrownewError('Could not find user object');
}
}
try{
registerUser(user);
}catch(err){
// Do something with the Error instance that was thrown.
}
This is how throw is typically used, but there's no rule that says it needs to be used for errors! The React team cleverly realized that this mechanism would allow the Suspense boundary to “catch” the fetch request (and any other promise-based loading states), from any descendant component within the boundary.
Within React's reconciler implementation, there's a try / catch block that catches the thrown promise, and suspends the render until that promise is resolved.
And so, it's not a metaphor: Suspense boundaries really are like error boundaries, except they catch loading states instead of errors.
This is weird, wild stuff. There's a reason that the React team expects library authors to come up with a smoother interface for this stuff. 😅
If your head is spinning, please know that it isn't critical that you understand this mechanism. It's an implementation detail. You'll never actually have to throw a promise in your own work; this is done under the hood by frameworks and libraries. I shared this technical deep dive because I find it really interesting, but it doesn't really change how we use Suspense.
The most important thing is that you understand the general mental model, the “rock concert” stuff about how the show can't start until all of the bandmates are ready.
This was the original 2018 vision for Suspense: a tool that would help us consolidate loading states, to avoid “Spinner Hell”. In the years since, however, the React team has realized that Suspense boundaries are even more powerful when combined with Server Side Rendering.
That's what we'll talk about in the next lesson.
Understanding Suspense • Josh W Comeau's Course Platform